iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Mobile Development

我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅系列 第 29

Day 29 - Flutter 效能優化:從卡頓到流暢的實戰經驗

  • 分享至 

  • xImage
  •  

大家好,歡迎來到第二十九天!在 Day 28,我們學習了 Cursor + Figma MCP 的 AI 驅動設計開發一體化。今天,我們要面對一個關鍵問題:App 效能

在開發 Crew Up! 的過程中,我們遇到了一些效能痛點:

  • 😣 首頁載入時背景圖片會閃一下
  • 😣 活動列表滾動時偶爾會卡頓
  • 😣 聊天室打開時要等一下才能看到訊息
  • 😣 圖片載入很慢,而且每次都重新下載
  • 😣 活動卡片的倒數計時似乎佔用不少 CPU

今天我們要分享實際在 Crew Up! 專案中完成的 7 項效能優化,以及具體的效果測量。

從發現問題到優化實作

在開始優化前,我們先用 Flutter DevTools 分析了整個 App:

  1. Performance Overlay:發現列表滾動時 FPS 不穩定,會掉幀
  2. Memory 分析:圖片佔用了大量記憶體且沒有釋放
  3. Network 追蹤:同一張圖片被重複下載多次
  4. Timeline:聊天室初始化有明顯延遲

🔧 Crew Up! 專案的 7 項效能優化實作

優化 1:圖片載入和快取優化 🖼️

遇到的問題:

在測試時發現,每次進入個人檔案頁面,大頭貼都要重新下載一次。用 DevTools 的 Network 分析後發現:

  • 同一張圖片被下載了 5-10 次
  • 網路流量消耗驚人
  • 使用者體驗很差(載入圈一直轉)

解決方案:

1. 安裝 cached_network_image 套件

dependencies:
  cached_network_image: ^3.4.1

2. 建立統一的快取圖片 Widget

// lib/common/widgets/cached_image_widget.dart
// (imports omitted)

class CachedImageWidget extends StatelessWidget {
  final String imageUrl;
  final double? width;
  final double? height;
  final BoxFit fit;
  final int? memCacheWidth;
  final int? memCacheHeight;

  @override
  Widget build(BuildContext context) {
    return CachedNetworkImage(
      imageUrl: imageUrl,
      width: width,
      height: height,
      fit: fit,
      memCacheWidth: memCacheWidth,
      memCacheHeight: memCacheHeight,
      placeholder: (context, url) => CircularProgressIndicator(),
      errorWidget: (context, url, error) => Icon(Icons.error),
    );
  }
}

3. 更新 ImageProviderUtils

// lib/common/utils/image_provider_utils.dart
// (imports omitted)

static ImageProvider getImageProvider(String imagePath) {
  if (imagePath.startsWith('http')) {
    return CachedNetworkImageProvider(imagePath);
  }
  return AssetImage(imagePath);
}

💡 專家提示:記憶體快取尺寸的黃金法則

在使用 CachedNetworkImage 時,memCacheWidthmemCacheHeight 參數至關重要:

CachedNetworkImage(
  imageUrl: url,
  width: 250,
  memCacheWidth: 500,  // 2x for Retina screens
)

為什麼要設為顯示尺寸的兩倍?

memCacheWidthmemCacheHeight 是以像素為單位。將其設定為顯示尺寸的兩倍,是為了讓圖片在記憶體中以更高解析度快取。這樣一來,當在高 DPI(如 Retina、2x/3x 螢幕)上顯示時,圖片能保持清晰銳利,避免因拉伸而變得模糊。

舉例來說:

  • 顯示尺寸:250 × 250 邏輯像素
  • Retina 螢幕(2x):實際需要 500 × 500 物理像素
  • 設定 memCacheWidth: 500 確保在高 DPI 螢幕上依然清晰

實際優化效果:

  • 網路流量大幅減少:圖片僅首次下載,後續從快取讀取
  • 圖片載入明顯更快:快取命中時幾乎瞬間顯示
  • 記憶體使用受控:智能限制快取大小(顯示尺寸 × 2)

如何驗證

  1. 開啟 DevTools 的 Network 監控
  2. 第一次載入圖片會看到網路請求
  3. 重新進入頁面,相同圖片不會再有網路請求

優化 2:ActivityCardWidget 倒數計時優化 ⏰

遇到的問題:

每個活動卡片都在 build() 方法中計算倒數時間,當列表有 20 個活動時,每次重建就要計算 20 次。

解決方案:

使用 ActivityCountdownProvider,由 Provider 統一管理倒數計時,每分鐘自動更新一次。

// lib/features/home/presentation/widgets/activity_card_widget.dart
// (imports omitted)

class ActivityCardWidget extends ConsumerWidget {
  final Activity activity;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final countdownState = ref.watch(
      activityCountdownNotifierProvider(activity.registrationDeadline),
    );

    return Text(countdownState.displayText);
  }
}

實際優化效果:

  • CPU 使用更穩定:倒數時間不再每次 build 都計算
  • 避免重複計算:Provider 每分鐘自動更新一次,所有卡片共享

優化 3:ListView 渲染優化 📜

遇到的問題:

列表滾動時 FPS 只有 45-50。檢查後發現:ListView 項目沒有 key,Flutter 無法正確重用 Widget。

解決方案:

改用 ListView.builder 並為每個項目加上 ValueKey

// lib/features/home/presentation/screens/activity_list_screen.dart
// (imports omitted)

Widget _buildActivityList(ActivityListState state) {
  if (state.activities.isEmpty) {
    return EmptyStateWidget(
      title: S.of(context).noActivities,
      cubiMessage: S.of(context).emptyPageStory,
    );
  }

  return ListView.builder(
    itemCount: state.activities.length,
    itemBuilder: (context, index) {
      final activity = state.activities[index];
      return ActivityCardWidget(
        key: ValueKey(activity.id), // 關鍵!讓 Flutter 正確重用
        activity: activity,
        onTap: () => _onActivityTap(activity),
      );
    },
  );
}

實際優化效果:

  • 滾動更流暢:掉幀明顯減少
  • Widget 重用率提升:切換篩選條件時,列表更新更快

優化 4:背景圖片預快取優化 🎨

遇到的問題:

首頁的背景圖片很大(2032 × 1500),每次進入都會看到圖片載入閃爍。

解決方案:

在首頁初始化時使用 precacheImage() 提前載入。

// lib/features/home/presentation/screens/index_screen.dart
// (imports omitted)

class _IndexScreenState extends ConsumerState<IndexScreen> {
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    precacheImage(
      const AssetImage('assets/index/index_background.png'),
      context,
    );
  }
}

💡 為什麼是 didChangeDependencies 而不是 initState

許多開發者可能會疑惑:為什麼不放在 initState 中?這個選擇展現了對 Flutter 生命週期的深刻理解:

  • initState:在 Widget 第一次被插入 Widget 樹時調用,此時 Widget 還沒有完全初始化,使用 context 可能會有問題
  • didChangeDependencies:在 initState 之後、build 之前被調用,此時 Widget 已經完全插入到 Widget 樹中,可以安全地使用 context

因為 precacheImage() 需要一個有效的 BuildContext 來存取 Theme、MediaQuery 等資訊,所以 didChangeDependencies 是執行預快取的理想時機

實際優化效果:

  • 首頁載入更快:背景圖片不再有明顯的載入過程
  • 完全消除閃爍:進入首頁時,背景圖片已經在記憶體中
  • 投資報酬率最高:只加 5 行程式碼,效果很明顯

優化 5:Message 聊天室效能優化 💬

遇到的問題:

聊天室是 Crew Up! 中效能問題最明顯的地方:

  1. 打開有延遲,要等一下才能看到最新訊息
  2. 滾動不夠流暢,訊息多的時候會掉幀
  3. 每次有新訊息都要重建許多 Widget

解決方案:

參考 WhatsApp、Telegram 等主流聊天 App,採用三個關鍵技術:

技術 1:使用 reverse: true ListView

// lib/features/message/presentation/widgets/message_chat_messages_widget.dart
// (imports omitted)

@override
Widget build(BuildContext context) => ListView.builder(
  controller: scrollController,
  reverse: true,  // 關鍵!最新訊息在 position 0(底部)
  cacheExtent: 500,
  itemCount: messages.length,
  itemBuilder: (context, index) {
    final message = messages[messages.length - 1 - index];
    return _MessageBubble(
      key: ValueKey(message.id),
      message: message,
    );
  },
);

技術 2:將訊息氣泡改為獨立 Widget

// ❌ 舊方式:方法
Widget _buildMessageBubble(Message message) {
  return Row(...);  // 每次都重建,無法被快取
}

// ✅ 新方式:獨立 Widget
class _MessageBubble extends StatelessWidget {
  final Message message;
  
  @override
  Widget build(BuildContext context) {
    // Flutter 可以正確快取和重用這個 Widget
  }
}

技術 3:簡化滾動邏輯

// lib/features/message/presentation/screens/message_chat_screen.dart
// (imports omitted)

void _scrollToBottom() {
  if (!_scrollController.hasClients) return;
  
  final isNearBottom = _scrollController.position.pixels < 200;
  if (isNearBottom) {
    _scrollController.animateTo(
      0,  // reverse: true 時,0 是最新訊息
      duration: const Duration(milliseconds: 300),
      curve: Curves.easeOut,
    );
  }
}

為什麼 reverse: true 這麼神奇?

比較項目 正常順序 reverse: true
初始位置 需要滾動到底部 自動在底部 ✅
渲染效能 需計算全部高度 只渲染可見區域 ✅
代碼複雜度 低 ✅

實際優化效果:

  • 聊天室初始載入:瞬間顯示,不再需要等待
  • 滾動流暢度:用 Performance Overlay 可以看到幾乎沒有紅色掉幀條
  • 程式碼更簡潔:移除了 50+ 行複雜的滾動邏輯

優化 6:水平滾動列表優化 ↔️

遇到的問題:

首頁有兩個水平滾動列表(熱門活動、我的活動),滾動時也有輕微卡頓。

解決方案:

為水平列表的項目加上 ValueKey

// lib/features/home/presentation/widgets/popular_activity_list_widget.dart
// (imports omitted)

ListView.separated(
  scrollDirection: Axis.horizontal,
  itemBuilder: (context, index) {
    return PopularActivityCardWidget(
      key: ValueKey(activities[index].id),
      activity: activities[index],
    );
  },
)

實際優化效果:

  • 水平滾動更順暢:輕微卡頓感消失
  • Widget 重用機制完善:整個 App 的列表都有 key

優化 7:善用 const 關鍵字,避免不必要的重建 🧱

遇到的問題:

在 DevTools 中,我們注意到即使沒有數據變化,某些靜態的 UI 組件(如標題、提示文字、空狀態頁面)有時也會跟著父組件一起被重建(rebuild),造成輕微的效能浪費。

解決方案:

對於那些創建後就不會再改變的 Widget,在建構函式前加上 const。這會告訴 Flutter,這個 Widget 是不可變的。在下次 build 時,如果父組件觸發重建,Flutter 會直接跳過這個 const Widget,甚至不會調用它的 build 方法。

1. 將 EmptyStateWidget 改為 const

// lib/common/widgets/empty_state_widget.dart
// (imports omitted)

class EmptyStateWidget extends StatelessWidget {
  final String title;
  final String cubiMessage;
  
  const EmptyStateWidget({ // 將建構函式改為 const
    super.key,
    required this.title,
    required this.cubiMessage,
  });
  
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(title),
        Text(cubiMessage),
      ],
    );
  }
}

2. 使用時也加上 const

// lib/features/home/presentation/screens/activity_list_screen.dart
// (imports omitted)

Widget _buildActivityList(ActivityListState state) {
  if (state.activities.isEmpty) {
    return const EmptyStateWidget( // 調用時也加上 const
      title: '沒有活動',
      cubiMessage: '快來創建一個吧!',
    );
  }
  // ...
}

💡 const 的強大之處

當您標記一個 Widget 為 const 時:

  1. 編譯時創建:Widget 在編譯時就被創建,而非每次 build 時
  2. 記憶體共享:所有相同的 const Widget 共享同一個記憶體實例
  3. 跳過重建:Flutter 完全跳過這個 Widget 的重建工作

實際優化效果:

  • CPU 使用降低:Flutter 跳過了對整個 const Widget 子樹的重建工作
  • 記憶體使用減少:相同的 const Widget 只占用一份記憶體
  • UI 更穩定:在 Performance Overlay 中,因 const 而跳過重建的部分變多了

適用場景:

  • 靜態文字、圖示
  • 空狀態頁面
  • Loading 指示器
  • 所有參數都是編譯時常數的 Widget

💭 實際優化過程的心得

1. 圖片快取是投資報酬率最高的優化

一開始以為效能問題在演算法或狀態管理,結果用 DevTools 發現:圖片載入是最大的效能瓶頸!

觀察到的現象:

  • 同一張圖片被重複下載 5-10 次
  • Network 面板充滿了圖片請求

改用 cached_network_image 後,整個 App 的感覺完全不同。

關鍵學習:優化要從最大的瓶頸下手,用工具觀察而不是憑感覺猜測。

2. reverse: true 改變了聊天室開發方式

最初花了很多時間寫複雜的滾動邏輯,結果發現業界標準做法就是用 reverse: true一行程式碼解決所有問題!

關鍵學習:遇到複雜邏輯時,先研究看看業界是怎麼做的。

3. ValueKey 是效能優化的銀彈

只需要一行程式碼:

key: ValueKey(item.id)

就能讓 Flutter 正確重用 Widget,減少大量不必要的重建。

關鍵學習所有動態列表都應該加 key,這是必須的基本優化。


📊 整體效能提升總結

完成的 7 項優化

# 優化項目 修改檔案 核心技術 觀察到的效果
1 圖片快取 7 個 cached_network_image 網路流量大幅減少,載入明顯變快
2 倒數計時 1 個 ConsumerWidget + Provider CPU 使用更穩定
3 垂直列表 1 個 ValueKey + ListView.builder 滾動更流暢,掉幀減少
4 背景預快取 1 個 precacheImage() + didChangeDependencies 首頁無閃爍,載入更快
5 聊天室 2 個 reverse: true + 獨立 Widget 初始化瞬間,滾動流暢
6 水平列表 2 個 ValueKey 水平滾動更順暢
7 const 優化 多個 const 關鍵字 CPU 使用降低,記憶體減少

總計:修改 14+ 個檔案,新增 1 個檔案,新增 1 個套件

觀察到的效能改善

🌐 網路與載入

  • 網路流量:明顯減少(圖片快取)
  • 圖片載入:快取命中時瞬間顯示
  • 首頁載入:背景圖無閃爍
  • 聊天初始:瞬間顯示最新訊息

⚙️ CPU 與渲染

  • CPU 使用:更穩定(避免重複計算)
  • 列表滾動:掉幀明顯減少
  • 聊天渲染:初始化瞬間

💾 記憶體與重用

  • Widget 重用:大幅提升(所有列表都有 key)
  • 記憶體使用:更穩定(快取控制)
  • 長時間使用:記憶體不會持續增長

如何自己測量

  1. 使用 flutter run --profile 運行
  2. 開啟 DevTools 的 Performance Overlay
  3. 觀察滾動時的 FPS 和掉幀情況
  4. 使用 Network 面板觀察圖片快取效果

🎓 核心技術要點

1. ValueKey 的重要性

// ❌ 沒有 key
ActivityCard(activity: activity)

// ✅ 有 key
ActivityCard(
  key: ValueKey(activity.id),
  activity: activity,
)

適用於所有動態列表:ListView、GridView、聊天訊息等。

2. reverse: true 聊天標準做法

ListView.builder(
  reverse: true,
  cacheExtent: 500,
  itemBuilder: (context, index) {
    final item = items[items.length - 1 - index];
    return Widget(key: ValueKey(item.id), data: item);
  },
)

3. 圖片記憶體快取策略

黃金法則:顯示尺寸 × 2

CachedNetworkImage(
  imageUrl: url,
  width: 250,
  memCacheWidth: 500,  // 2x for Retina screens
)

4. 預快取關鍵資源

@override
void didChangeDependencies() {
  super.didChangeDependencies();
  precacheImage(const AssetImage('large_bg.png'), context);
}

5. const 關鍵字優化

// 建構函式加上 const
class MyWidget extends StatelessWidget {
  const MyWidget({super.key, required this.title});
  final String title;
  // ...
}

// 使用時加上 const
const MyWidget(title: 'Hello')

// 適用場景:所有參數都是編譯時常數的 Widget

🧪 測試與驗證

效能測試工具

# 使用 Profile 模式測試
flutter run --profile

預期觀察結果

測試項目 如何驗證 預期結果
列表滾動 開啟 Performance Overlay 綠色條為主,紅色條很少
圖片快取 查看 DevTools Network 第二次載入無網路請求
首頁載入 觀察背景圖片 無閃爍,立即顯示
聊天室 進入聊天室 立即看到最新訊息

🎯 總結

今天我們在 Crew Up! 專案中完成了 7 項效能優化,涵蓋了圖片、列表、狀態管理、滾動、Widget 重建等各個面向。透過 Flutter DevTools 的測量和觀察,成功將 App 從「卡頓」優化到「流暢」。

核心技術

從這次優化中,我們學到四個最重要的技術:

  1. ValueKey:所有動態列表的必要優化
  2. reverse: true:聊天 UI 的標準做法
  3. cached_network_image:圖片效能的完美解決方案
  4. const 關鍵字:減少不必要的 Widget 重建

與前幾天的關聯

  • Day 6:Riverpod 狀態管理 - 今天用它優化了倒數計時
  • Day 7:Repository Pattern - 快取策略建立在這個基礎上
  • Day 20:Firebase Performance Monitoring - 用它來測量優化效果
  • Day 28:Cursor + Figma - 這些工具幫助我們快速實作優化

下一步

明天(Day 30),我們將探討 Flutter 30 天挑戰的完整回顧與總結,回顧這段精彩的學習旅程,分享 Crew Up 專案的完整成果。

期待與您在 Day 30 相見!


📋 相關資源

Flutter 效能優化

套件文件

相關文章


📝 專案資訊

  • 專案名稱: Crew Up!
  • 開發日誌: Day 29 - Flutter 效能優化:從卡頓到流暢的實戰經驗
  • 文章日期: 2025-10-13
  • 技術棧: Flutter 3.5+, cached_network_image 3.4.1, Riverpod 2.6.1, Firebase Performance, Clean Architecture

上一篇
Day 28 - Cursor 進階功能 + Figma MCP:解決設計與開發的鴻溝
下一篇
Day 30 - 完賽心得:30 天的挑戰與成長
系列文
我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言